Skip to content

feat: Use the Handles, Luke! #427

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 33 commits into
base: main
Choose a base branch
from

Conversation

shanecelis
Copy link
Contributor

@shanecelis shanecelis commented Jun 27, 2025

I was only planning to file issue #426 tonight, which states the problems I was having. I thought if I poked around I'd find the implementation reason for why what I suggest there wouldn't work. So I made a branch and rolled up my sleeves, and I was able to pretty quickly get exactly what I wanted.

Handles

In the issue I suggest doing away with ScriptId. As I worked with the implementation, it became clear I actually wanted to redefine it.

// OLD
// type ScriptId = Cow<'static, str>; 
// NEw
type ScriptId = AssetId<ScriptAsset>;

Beyond that I made it so ScriptComponent holds Handle<ScriptAsset>s. The nice thing about this is you can use this pattern:

ScriptComponent(vec![asset_server.load("foo.lua")])

No need to store a strong handle somewhere so your script isn't unloaded. However, if you rely on that behavior you can still do that like you do with any other asset-based item using weak handles.

let strong_handle = asset_server.load("foo.lua");
ScriptComponent(vec![strong_handle.clone_weak()])

This preserves the semantics of the current release.

ScriptAsset loading versus evaluation

In this PR the static scripts are handled essentially the same as before except they can retain handles (both strong and weak). EDIT: A script may be loaded without evaluation. If it is added as a static script, it will then be evaluated.

The non-static scripts are not evaluated until they are added to a ScriptComponent. Then they are evaluated in order, sequentially.

ScriptAsset change

I suggested a change to ScriptAsset and that survived this refactoring.

pub struct ScriptAsset {
    /// The body of the script
    pub content: Box<[u8]>,
    /// The language of the script
    pub language: Language,
}

I also provided a ScriptSettings where one can provide a definite language. None will use the file's extension.

asset_server.load_with_settings("hello.p8", |settings: &mut ScriptSettings| {
   settings.language = Some(Language::Lua);
});

One downside about ScriptSettings is it required adding "serde" with the "deriv" feature as a dependency.

Example

I updated the Game of Life to work with Lua and Rhai and all its console options.

Remaining work

I have not tried to exercise any other languages or even do much with the tests. But I'd be happy to move this forward to handle the rest with some consensus that that's the right direction.

@shanecelis shanecelis changed the title Use the Handle, Luke! feat: Use the Handles, Luke! Jun 27, 2025
@shanecelis
Copy link
Contributor Author

I got the tests compiling and running. There are 9 failures with features 'lua' and 'rhai'.

@shanecelis
Copy link
Contributor Author

There is a regression in that it doesn't support multiple scripts. I'm trying to get that ready in the 'use-entities' branch, which is compiling now but not running yet if you wanted to take a peek. But maybe this PR needs to cook a little more. I'm gonna take another pass on it tonight.

@shanecelis
Copy link
Contributor Author

shanecelis commented Jun 29, 2025

Ok, I have fixed the regression. Game of life works in all ways for Lua. For Rhai it doesn't seem to work for static scripts.

So as I was trying to make scripts work with a shared context and per entity/script, I realized the split of contexts is actually a big policy decision. I imagine in most cases it'll come down to a few qualities: entity, script, and another thing I'm calling domain. To that I end I wrote this trait:

/// A generic script context provider
pub trait ScriptContextProvider<P: IntoScriptPluginParams> {
    /// Get the context.
    fn get(&self, id: Option<Entity>, script_id: &ScriptId, domain: &Domain) -> Option<&Arc<Mutex<P::C>>>;
    /// Insert a context.
    fn insert(&mut self, id: Option<Entity>, script_id: &ScriptId, domain: &Domain, context: P::C) -> Result<(), P::C>;
    /// Returns true if there is a context.
    fn contains(&self, id: Option<Entity>, script_id: &ScriptId, domain: &Domain) -> bool;
}

My hope is this would allow us to provide the right criteria to choose contexts divisions.

SharedContext

So the shared context is implemented with this:

pub struct SharedContext<P: IntoScriptPluginParams>(pub Option<Arc<Mutex<P::C>>>);

and it ignores the arguments and just provides the same context to everyone.

EntityContext

A per entity context is implemented like this:

/// Stores the script context by entity.
pub struct EntityContext<P: IntoScriptPluginParams>(HashMap<Entity, Arc<Mutex<P::C>>>);

ScriptContext composition

And the resource we make available is a composition:

#[derive(Resource)]
/// Keeps track of contexts
pub enum ScriptContext<P: IntoScriptPluginParams> {
    /// One shared script context
    Shared(SharedContext<P>),
    /// One script context per entity
    ///
    /// Stores context by entity with a shared context as a last resort when no
    /// entity is provided.
    Entity(EntityContext<P>, SharedContext<P>)
}

And if we wanted to we could other provide other policies like by ScriptId or something.

Domain

But what's domain? It's the old ScriptId sort of, maybe?

/// A kind of catch all type for script context selection
///
/// I believe this is what the original ScriptId was intended to be.
pub type Domain = Option<Cow<'static, str>>;

I don't do anything with domain presently, and if you think it's extraneous we can toss it.

My vision for domain was to allow users to clump scripts together a little more haphazardly. Something like the following fantasy code would put scripts into one of two domains:

asset_server.load_with_settings("player.lua", |settings: &mut ScriptSettings| {
   settings.domain = Some("player".into());
});
asset_server.load_with_settings("monster.lua", |settings: &mut ScriptSettings| {
   settings.domain = Some("env".into());
});
asset_server.load_with_settings("effects.lua", |settings: &mut ScriptSettings| {
   settings.domain = Some("player".into());
});

Take away

This code is working and I think it's ready for your attention.

Having spent more time with this code base I'm in even more awe of what you've put together. Let me know if there's anything I can do to help. I'm sorry that this branch is probably going to conflict with 0.16 branch. :(

Further work

I'm going to try to make this branch work with Nano-9 to dog food it in the coming days.

@shanecelis
Copy link
Contributor Author

shanecelis commented Jun 30, 2025

It was quick and painless to switch over to this branch in Nano-9. See the commit.

@shanecelis
Copy link
Contributor Author

shanecelis commented Jul 1, 2025

Domains

I went ahead and fleshed out the Domain idea and dropped the Option from its type, but in almost every parameter it is an Option<Domain> now.

pub type Domain = Cow<'static, str>;

How to select a domain?

I thought about adding a 'domain' field to ScriptAsset, but I ultimately decided against it because a script could be used in multiple domains. Instead I just used a component; no component, no domain.

/// Defines the domain of a script
#[derive(Component)]
pub struct ScriptDomain(pub Domain);

Static scripts

I could allow for static scripts to have a domain but I have left them all as being in no domain. Are static scripts grouped into their own context? Are static scripts a kind of domain? Are they superseded by domains?

Add support for context per script

I added with_domains() and per_script().

impl<P: IntoScriptPluginParams> ScriptContext<P> {
    /// Use a shared script context
    pub fn shared() -> Self {
        Self::Shared(SharedContext::default())
    }
    /// Domain contexts or a shared context
    pub fn with_domains() -> Self {
        Self::Domain(DomainContext::default(), SharedContext::default())
    }
    /// Use one script context per entity
    pub fn per_entity() -> Self {
        Self::Entity(EntityContext::default(), SharedContext::default())
    }
    /// Use one script context per entity
    pub fn per_script() -> Self {
        Self::ScriptId(ScriptIdContext::default())
    }
}

Now that there are 4 choices, I'd suggest we change the enable_shared_context to instead pick a default. If the user wants to change it they do:

app.insert_resource(ScriptContext::shared());

Problem: N scripts produce N callback evaluations even with 1 shared context

I added a Recipients::Domain and patching everything up I dealt with an issue I came across as soon as I started using multiple scripts in BMS. If I have five scripts in a shared context, and I do a callback for _update() with Recipients::All, _update() is called FIVE TIMES! One can argue that's correct behavior, but I found it surprising. It's not surprising when every script is in its own context. I worked around this issue in Nano-9 by just taking note of one ScriptId and only calling that. However, updating the code I found an opportunity to implement a general solution.

Proposal: Evaluate Callbacks on a Per Context Basis

Here is my take for least-surprise: If I have five scripts all in one shared context, and I fire a call to Recipients::All, then I want that call to go to the shared context once.

If I have four scripts in three different contexts, and I fire a call to Recipients::All, then I want that call to be evaluated three times, once in each context.

To facilitate that, I added the following method:

/// A generic script context provider
pub trait ScriptContextProvider<P: IntoScriptPluginParams> {
    // ... 
    /// Hash for context.
    ///
    /// Useful for tracking what context will be returned by `get()` without
    /// requiring that `P::C` impl `Hash` and cheaper too.
    fn hash(&self, id: Option<Entity>, script_id: &ScriptId, domain: &Option<Domain>) -> Option<u64>;
}

What I think is nice about this is it works the same as before if you're placing every script in its own context. But it will also work with scripts, entities, domains, and shared contexts.

Docs?

Would you like some help updating the docs/book for this PR?

@shanecelis
Copy link
Contributor Author

I'm prepping this branch for a BMS 0.14.0.

Expecting some real-world use I updated the ScriptContext<P> to allow for the following kinds:

  • ScriptContext::shared()
  • ScriptContext::per_script()
  • ScriptContext::per_entity()
  • ScriptContext::domains()
  • ScriptContext::shared().with_domains()
  • ScriptContext::per_script().with_domains()
  • ScriptContext::per_entity().with_domains()

And its default is ScriptContext::per_script() so that it follows the conventions of its preceding versions.

I found some tests failing (and some not even compiling--eek). I'll try to fix them tomorrow.

@shanecelis
Copy link
Contributor Author

I keep thinking I'm close to being done, but then cargo unveils some new slew of errors. After much teeth gnashing, I have all 172 tests passing when I run "cargo test" from the root workspace. However, when I go into the core crate's directory and run "cargo test" I get compilation errors from the tests. Shouldn't cargo run all the workspace crates' tests?

Anyway, fixing the test harness unveiled this bug:

  • If you added your ScriptAsset w/o AssetServer, it wouldn't work. (Now fixed.)

To fit better with ScriptContextProvider<P> which uses an Option<Entity>, I refactored the instances of Entity::from_raw(0) to be Nones.

I didn't seek out to do this, I think I was bewildered by the test harness not working, but as I was patching things up, I realized that we could load scripts without cloning their contents if they're given via a Handle<ScriptAsset>. So that was just a little performance commit as a treat.

Will continue to work on this until I get all tests to pass. Do you have a magic command you provide on the command line to run all the tests?

@makspll
Copy link
Owner

makspll commented Jul 5, 2025

try cargo xtask test it's a rust-based makefile essentally!

@shanecelis
Copy link
Contributor Author

Progress but cargo xtask test remains unfinished.

@shanecelis
Copy link
Contributor Author

It's all compiling now. 3 tests are failing. Unfortunately the fix seems a little hairy. The called_on_right_recipients test in particular requires a change in the event handler to fix.

My workday has been interrupted quite a bit, so I put my partial commits into my "use-handles-dev" branch so it's not distracting in this PR. I changed the event handler and now 5 tests are failing. On it goes.

@shanecelis
Copy link
Contributor Author

All tests pass! The game of life example starts, stops with Lua & Rhai with and without static argument.

Assumption Breakage

I had been adding Option<Entity> and Option<Domain> parameters and fields as necessary, but the assumption of one context per script handle was still embedded and becoming awkward. I decided to bite the bullet and break with that assumption with this struct:

pub struct ContextKey {
    /// Entity if there is one.
    pub entity: Option<Entity>,
    /// Script ID if there is one.
    pub script_id: Option<Handle<ScriptAsset>>,
    /// Domain if there is one.
    pub domain: Option<Domain>,
}

It's the new key to context.

/// A generic script context provider
pub trait ScriptContextProvider<P: IntoScriptPluginParams> {
    /// Get the context.
+    fn get(&self, context_key: &ContextKey) -> Option<&Arc<Mutex<P::C>>>;
-    fn get(&self, id: Option<Entity>, script_id: &ScriptId, domain: &Option<Domain>) -> Option<&Arc<Mutex<P::C>>>;
    // ...
}

What's in a Domain?

With the inclusion of Domain in the ContextKey, I wanted it to be Copy if possible, so I altered its defintion to this:

/// A kind of catch all type for script context selection
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
pub struct Domain(u64);

impl Domain {
    /// Create a domain handle.
    pub fn new(hashable: impl Hash) -> Self {
        Domain(DefaultHashBuilder::default().hash_one(hashable))
    }
}

New is Old

This is the new default ScriptContext variant that captures the prior version's behavior and then some.

    DomainEntityScriptId(Or<DomainContext<P>, Or<EntityScriptIdContext<P>, Or<ScriptIdContext<P>, SharedContext<P>>>>),

You can read the Ors as short-circuiting like this:

  1. If there is a domain, DomainContext provides a context, or
  2. if there is an entity and script, EntityScriptIdContext is used, or
  3. if there is a script (which is the case for StaticScripts that have no entity), ScriptIdContext is used, or
  4. if there is no domain, entity, or script, then SharedContext is used.

The prior version had behaviors 2 and 3 and 4 as a separate assignment mode. This adds domains without interfering with the rest if they're not used.

And its easy to design different behaviors within the library. For instance, if we wanted to have all static scripts live in the same context we can drop the ScriptIdContext.

    DomainX(Or<DomainContext<P>, Or<EntityScriptIdContext<P>, SharedContext<P>>>),

This means SharedContext will provide contexts for any context key that only has a script ID like static scripts. (We could also make static scripts work with domains as an alternative that's more flexible.)

Reify me not

I did a big refactor on the event_handler_inner<P: IntoScriptPluginParams> function to make it work with the ScriptContext. I chose some code duplication for hopefully better runtime performance. Is there any performance tests I could exercise to compare with the previous version?

It now has these benefits:

  • Early exits
  • Does not query and reify all scripts to a collection every call
  • Queries one entity only when necessary
  • Queries scripts only when necessary

Apologies

I am sorry that this PR has gotten so big. At the beginning it seemed like this could be a less invasive set of changes. Having failed to make this change set small, I have endeavored to make it good.

@shanecelis
Copy link
Contributor Author

I updated the BMS book's chapter on contexts and managing scripts.

I realized through the original documentation that I had misunderstood BMS's original script context isolation. I thought it somehow isolated scripts on an entity-script pair basis. I wrote the entity-script pair context so it would be a drop-in substitute for the original system. Oops. Turns out isolating based on script would be the drop-in replacement.

Choose ScriptContext::default()

Now's the time to choose the default.

  • Use ScriptContext::per_script().with_domains(). This is the closest to the preceding version's behavior.
  • Use ScriptContext::per_entity_and_script().with_domain(). This is maximally isolated script execution, and perhaps less surprising since the scripts don't share a context between entities.

The default is currently set to the latter.

Migration Guide

I plan to also write a chapter on migrating from one version to another imitating Bevy's excellent migration guides.

@makspll
Copy link
Owner

makspll commented Jul 13, 2025

Ello, apologies for not interacting much yet, rest assured I am observing the branch and trying to grok it (and I like this direction), I've now gotten the bevy migration branch to a final state, so now I am going to be reviewing the changes in this PR over the next week or so, do you mind merging the last bit of conflicts (as I need the xtask changes to load the workspace locally).

for the merge order, now I am thinking:

  • release from this branch for a handles+bevy0.15 version via staging, perhaps as an alpha
  • merge bevy 0.16 migration to main, release that as no-handles+bevy0.16
  • rebase this branch against that, to get the handles+bevy0.16 version

thoughts?

This is a combination of 68 commits.

hack: It compiles.

hack: Compile with lua54 feature.

refactor: Accept Handle<ScriptAsset> in ScriptComponents.

feature: Handle static scripts.

bug: Fix rhea and static script unloading.

feature: Add ScriptSetttings to loader.

This introduces a serde dependency though. :(

test: Prep tests to run.

feature: Add DisplayProxy. Refactor continues.

refactor: Bring test harness in line.

test: Tests are running!

test: Make test runnable without rhai.

excise: Remove ScriptMetadata and ScriptEvent.

We don't need them since asset's know their language.

excise: Drop Script::asset field.

It's a duplicate with Script::id now.

chore: fix build problem in xtask

perf: Can create_or_update_script w/o clone.

feature: Try to use the entities.

hack: It compiles but idk why.

I fought with HandlerContext trying to add a Query to it for the longest
time.

hack: Compiles but doesn't run well.

Log is flooded with these two lines:
```log
2025-06-28T11:46:54.321715Z ERROR bevy_mod_scripting_core::handler: Rhai: Failed to query entities with scripts: Cannot claim access to base type: Global. The base is already claimed by something else in a way which prevents safe access. Location: "/Users/shane/Projects/bevy_mod_scripting/crates/bevy_mod_scripting_core/src/extractors.rs:337". Context: Could not claim exclusive world access
2025-06-28T11:46:54.322046Z ERROR bevy_mod_scripting_core::handler: Lua: Failed to query entities with scripts: Cannot claim access to base type: Global. The base is already claimed by something else in a way which prevents safe access. Location: "/Users/shane/Projects/bevy_mod_scripting/crates/bevy_mod_scripting_core/src/extractors.rs:337". Context: Could not claim exclusive world access
```

feature: Use SharedContext(P::C) for global context.

feature: GOL works with SharedContext.

feature: Works with ScriptContextProvider trait.

refactor: It's all coming together now.

bug: Make per-entity work with static scripts.

doc: Clarify EntityContext. Hide internal struct.

chore: fix new clippy warnings causing build errors (makspll#428)

partial: Add domain handling.

feature: Add ScriptContextProvider::hash member.

feature: Call labels on a per-context basis.

doc: Explain call per-context rationale.

feature: More ScriptComponentProvider<P> variants.

chore: Restore commented log plugin.

test: Remove script_id_generator parameter.

test: Remove metadata tests.

refactor: Prefer Option<Entity> vs Entity::from_raw(0).

bug: Scripts w/o AssetServer didn't work.

Now you can add scripts via `Assets<ScriptAsset>` or the `AssetServer`.

test: Fix tests.

perf: Avoid cloning script content when possible.

doc: Document script::Or.

refactor: Unscore unused parameters in context providers.

chore: Fix it, Clippy!

doc: Add note about ScriptContext::hash().

test: Fix/refactor tests.

feature: Remove todo! & impl supported extensions.

Implement the ConfigureScriptAssetSettings again.

chore: Make core tests compile.

test: It compiles! 8 test failures.

test: All tests passing except 3.

partial: Handle event rework.

feature: Add iter() to ScriptContextProvider<P>.

refactor: Rework event handling.

partial: Collecting contexts.

partial: More handler event mulling.

feature: New event handler.

Early exit. Fewer allocations. No querying unless necessary.

feature: Add remove() to ScriptContextProvider<P>.

feature: Make DeleteScript delete contexts too.

feature: Add EntityScriptIdContext and ContextKey.

feature: Rename iter() to values().

feature: Add iter() to ScriptContextProvider<P>.

feature: Handle Recipient::ScriptId fully.

Static scripts included. Fewer clones.

refactor: Move ContextKey to its own file.

feature: Made Domain(u64).

refactor: Weave ContextKey in.

It'd be nice if ContextKey had a Handle<ScriptAsset> just for logging purposes.

refactor: Use ContextKey. Tests pass!

refactor: Use ContextKey instead of ScriptId.

chore: Clean up warnings.

bug: Fix game of life.

feature: Add prelude.

I like explicit imports, but I'm trying to make the code in mdbook
runnable, just so it is easier to maintain, and it feels like it'd be a
mess without a prelude.

doc: Update book.

A few code refactors to make book writing easier.

feature: Add ConfigureScriptAssetSettings to prelude.
@shanecelis
Copy link
Contributor Author

Great to hear!

I think the versions sound fine. A handles+bevy0.15 as an alpha is good by me, better even than polluting the version space with use-handles and not.

rebase this branch against that [no-handles+bevy0.16], to get the handles+bevy0.16 version

I think the desired end is good, but I worry the process of rebasing this branch onto a 0.16 will be a chore. But maybe it won't. To update this branch to "main," I'm going to squash everything in this branch just to make the rebasing easier. Maybe it won't be as troublesome if the commits are fewer, but I was initially thinking I'd take the patch from the old handles+0.16 and apply that on top of this branch with whatever fix up was required.

Reload and unload don't seem to be working yet.
@shanecelis
Copy link
Contributor Author

I squashed this branch and rebased it to main. It's compiling. There are two tests which are failing.

failures:
    commands::test::test_commands_with_default_assigner
    commands::test::test_commands_with_global_assigner

And I'm not seeing the unload and reload work yet. Probably need to add a test for those.

@makspll
Copy link
Owner

makspll commented Jul 14, 2025

I think the desired end is good, but I worry the process of rebasing this branch onto a 0.16 will be a chore. But maybe it won't. To update this branch to "main," I'm going to squash everything in this branch just to make the rebasing easier. Maybe it won't be as troublesome if the commits are fewer, but I was initially thinking I'd take the patch from the old handles+0.16 and apply that on top of this branch with whatever fix up was required.

Ah yeah sorry by rebase I mean rebase OR merge, don't mind which, squash and rebase is also good! Folks at work treat those words interchangably and it's infected me

@makspll
Copy link
Owner

makspll commented Jul 14, 2025

Nice, I will pull this down locally and play with it

@shanecelis
Copy link
Contributor Author

Fixed the failing assignment tests. All tests are passing!

I added an example called "run-script" to exercise the reload functionality. It's a mouthful to run reload.lua because of the needed features:

cargo run --features lua54,bevy/file_watcher,bevy/multi_threaded --example run-script -- reload.lua

I know you're looking to get more examples, so I figured this might be good to have for the rust part and maybe putting together quick pure lua or rhai examples as well.

Reload functionality is not working yet in this branch.

This error persists for a variable `output` which I DO NOT SEE in the
code it references.
```
warning: unused variable: `output`
    --> crates/bevy_mod_scripting_functions/src/core.rs:419:8
     |
 419 |     fn add_system(
     |        ^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_output`
     |
     = note: `#[warn(unused_variables)]` on by default
```
@shanecelis
Copy link
Contributor Author

One clippy warning remains but I don't know why. It refers to an "output" variable that isn't there. See last commit for the exact log.

@makspll
Copy link
Owner

makspll commented Jul 20, 2025

🚧 WIP: Review in progress🚧

Alright, the PR changes quite a few things I am going to use the following convention in my reviews:

  • 🤷 Things of note, not requesting any changes / starting a discussion that might lead to changes requested, would like to hear your thoughts
  • 🛑 Things that will block merging, I would like to see changes before going further.

And do keep in my mind I am happy to make those changes, so do let me know if you want me to!

I'll also divide my thoughts into the areas that were touched:

🛑/🤷 Script asset events directly tied to script asset handler systems

https://github.com/shanecelis/bevy_mod_scripting/blob/67c5a5d08c5f88f1865849013ca292fd5d009b40/crates/bevy_mod_scripting_core/src/asset.rs#L61-L65

This was previously decoupled via proxy script events for two reasons:

  1. changes to bevy asset events were easier to migrate, and the logic downstream does not have to worry about the nuances within bevy's own events ( I.e. we don't care about asset dependencies, and shouldn't need to distinguish here)
  2. as a staging ground for the script metadata resources (i.e. we would link up all the script metadata at the points we would synchronize these events)

I believe this was removed in this PR since the metadata synchronising is no longer necessary, but for the sake of 1. I'd personally still see value in the decoupling, maybe via:

pub(crate) enum ScriptAssetEventKind {
    Added,
    Attached,
    Detached,
    Removed,
    Modified,
}

and a matching struct with the payload, or repeated enum constructors whatever's easier.

It seems we also do not handle AssetEvent::Loaded and AssetEvent::LoadedWithDependencies at all ❓. How do we handle the initial load of scripts?

EDIT: I see we do not ever do this via assets anymore, which makes sense, I think pulling out script events into their own thing, and then dispatching those events from the new component changes source (i.e. Added) would be neat

🛑 Script evaluation queue feels unnecessary

Relates to my first point, if say we coalesced all script events into our "Script Event" dimension first, and then populated those events from the new sources (i.e. Added script component = ScriptEvent::Attached), we could handle everything in a pretty unified way.

And in this area, the queue is used to collect all these script related events (currently, ScriptAdded, and the static script equivalent), and then immediately consume it.

I'd like to see:
sync assets: AssetsEvents -> ScriptEvent::Modified | ScriptEvent::Removed
sync components: Component Added -> ScriptEvent::Added | ScriptEvent::Attached | ScriptEvent::Detached (I am not sure if removing the component should actually unload the script, I guess it depends)

handle script events: ScriptEvent -> Script Commands

Not sure about the exact event details though, added vs attached etc, Do we need new commands? Attached Vs Detached ?

🤷 ScriptContexts enum is very complex

What we're doing is mapping:

  • a combination of keys to a corresponding context

by embedding this strategy in the enum as is we're implying that to read any of the contexts, you have to do this differently per policy we are using.

My immediate thought would be to have the context store as a completely separate concept, i.e. a HashMap<ContextId, Context>,
then how you map the various keys to a context, is a separate decision from the insertion/retrieval etc, all you have to ensure is the scripts you want sharing contexts, map to the same contextID. (In fact ContextID's used to be a thing before I inlined them into the scripts in #350, for the auto-clean up benefits).

The Domain concept you've come up with then appears for free, as you can map to arbitrary ID's based on the set of keys we make available (assuming you'd be driving domain off the asset paths etc)

@shanecelis
Copy link
Contributor Author

Thanks for taking the time to look over it.

Script asset events

I believe this was removed in this PR since the metadata synchronising is no longer necessary

That was the reason, but I think you're right that ScriptEvents should be reconstituted especially since now the events are not aligned with the AssetEvent.

Script evaluation queue feels unnecessary

It started as a system Local before I was compelled to pull it out for static scripts.

I realized too that the queue isn't necessary unless you're putting scripts into the same context.

I will attempt to do something as you suggest but I admit I'm foggy on what that will entail.

ScriptContext enum is very complex

That's a really good point. It's perhaps a labor of understanding that we can now schluff off, but I think you're right. We could get the same effect by having a HashMap<ContextKey, P::C> and applying some filter to the ContextKey before hand:

fn shared(context_key: ContextKey) -> ContextKey {
    ContextKey::default()
}

fn per_script(context_key: ContextKey) -> ContextKey {
    ContextKey {
      script: context_key.script,
      ..default()
    }
}

Oh, I see perhaps you'd suggest Fn(&ContextKey) -> ContextId rather. It does seem like ContextId wants to be expressed somehow.

I kind of liked how script handles were a proxy for context handles—when all script handles were dropped, the context was dropped—and it has made me wonder if Contexts ought to be assets with their own handles. [Shrug.]

The Domain concept you've come up with then appears for free, as you can map to arbitrary ID's based on the set of keys we make available

Hmm, I don't quite follow but I'm intrigued by the idea. What's the type of ContextId in your mind?

(assuming you'd be driving domain off the asset paths etc)

I hadn't thought domains would be related to asset paths. Best example I have for their potential use is what I used in the book example:

commands.spawn((
    ScriptComponent(vec![asset_server.load("player.lua")]),
    ScriptDomain(Domain::new("player")),
)).with_children(|parent| {
    parent.spawn((
        ScriptComponent(vec![asset_server.load("sword.lua")]),
        ScriptDomain(Domain::new("player")),
    ));
})

@shanecelis
Copy link
Contributor Author

shanecelis commented Jul 23, 2025

I completely reworked the ScriptContext, and I think it's much better now. I dropped all those ScriptContextProvider modules. I dropped the ScriptContextProvider trait. Took out a lot of lines of code. Yay!

We use a simple enum ContextRule that allows for custom rules. The user can now configure policies much more flexibly, e.g., [ContextRule::Domain, ContextRule::Custom(custom_context_selection_fn), ContextRule::Script, ContextRule::Shared].

I've reintroduced the ScriptEvent, and ScriptQueue has been demoted to a Local and type alias (and this incidentally fixed a latent bug that would have become apparent when multiple runtimes were mucking with one ScriptQueue).

I haven't sorted out the tests yet.

There is a complication with using the ContextKey directly as the key in ScriptContext: Because the script field is an Option<Handle<ScriptAsset>>, I worry that a strong handle vs a weak handle for the same asset may not hash or == as we might like. I need to either ensure we only store weak handles or validate that the operations for search and insert won't depend on the handle being strong or weak. In my 'use-handles-dev' I have a commit that changes ContextKey.script to be a Option<ScriptId> but then we'll lose a lot of the nicer error reporting that can show you the path of the script during logging. EDIT: Handles are hashed and == on their IDs, so no problem there!

@shanecelis
Copy link
Contributor Author

All right. This is hopefully looking more like what you asked.

DeleteScript is kind of doing double duty. It deletes a static script and it deletes a context. Perhaps a DeleteContext should be extracted out from it. I don't know.

Also since we're storing ContextKeys as our key, I'll just note I'm only storing weak handles for scripts.

@makspll
Copy link
Owner

makspll commented Jul 23, 2025

Looking really good! The events make this extremely easy to read!

@shanecelis
Copy link
Contributor Author

Starting with this branch, I re-applied Alex Parlett's 0.16 patch which is now on available on use-handles-0.16.

@makspll
Copy link
Owner

makspll commented Jul 26, 2025

I am just working out the last few kinks, namely:

  • we've lost the concept of "script residency", i.e. we can't ask if a script is loaded currently, this is the reason teh integration tests didn't work with the asset loader, the "is loaded" condition was taken out of the loop
  • fixing rhai bits and bobs
  • something going on with the benchmarks

@shanecelis
Copy link
Contributor Author

Re: script residency. [Looks at the test harness lib.rs:305 file.] Oh, that's right. I imagine you're looking at this code:

        if let Some(event) = error_events.into_iter().next() {
            // eprintln!("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx");
            // if ! app.world().resource::<AssetServer>().load_state(&handle).is_loaded() {
            //     continue;
            // }
            return Err(event
                .error
                .display_with_world(WorldGuard::new_exclusive(app.world_mut())));
        }

I commented that out, but the script is being added a few lines above lib.rs:244.

    // The following code did not work, possibly because of the asynchronous
    // nature of AssetServer.
    //
    // ```
    // let handle = app.world_mut().resource_mut::<AssetServer>().load(&script_path);
    // app.world_mut().spawn(ScriptComponent::new([handle.clone()]));
    // ```
    let handle = {
        let mut script_dir = manifest_dir.clone();
        script_dir.push("assets");
        script_dir.push(script_id.path());
        // Read the contents and don't do anything async.
        let content = fs::read_to_string(&script_dir)
            .map_err(|io| format!("io error {io} for path {script_dir:?}"))?;
        let mut script = ScriptAsset::from(content);
        script.language = P::LANGUAGE;
        app.world_mut()
            .resource_mut::<Assets<ScriptAsset>>()
            .add(script)
    };

I was getting intermittent behavior if I recall correctly, so I figured if I could make script loading synchronous rather than async, it would be more reliable.

@makspll
Copy link
Owner

makspll commented Jul 28, 2025

oh yeah I get that, in the integration test though I'd ideally like to run the pipeline from start to end, for unit tests that makes a lot of sense.

I haven't quite gotten there this weekend as I'd hoped, I've bumped into a few more things I haven't noticed before (apologies, don't worry about patching to 0.16, I can continue from here), still have to cook a little longer, but the current TL;DR of what I am working on is:

  • Introducing ScriptAttachment enum, (i.e. EntityScript, StaticScript), which acts as the context policy "input", this lets us ask questions like, how many scripts live together in the same context
    • thanks to that we can automatically tell if a context needs deleting (if we just detached the last script living in the context, delete it)
    • we can also ask if a script has actually loaded (without this, in a shared context scenario, you will only know if at least one script has loaded into the shared context, but not THIS script)
  • sort of overhauling the Recipients enum, as I don't think Script and Entity fit the model that well anymore, might be best to shift the querying onto the user, and provide individual ScriptEntity and StaticScript as well as AllScripts and AllContexts (and Domain) variants, if people need to send more variations, they can simply query the ECS before sending, and if we want to batch, we can improve on this internally later
  • I see a lot of potential for simplification in this new model (I think it's even possible to just stop querying anything apart from the existing contexts in the event handler, since it only cares about what's actually loaded, i.e. is resident in a context), not sure if that possibility existed before, or if it's opened up just now, but might as well simplify now. If we want to notify users of unloaded scripts we can use a separate system with warnings
  • I think I'll also take this opportunity to write a slightly better harness for testing complex loading scenarios in an integration context, think scenario.json with list of events that happen, current context policy etc, since the complexity sort of blew up in this area, I'd like to improve the testing a bit
  • I am also experimenting with on_add and on_remove component hooks, to simplify the syncing process (let's you see what the removed component was before it's removed)

Haven't said it enough yet, but your work is much appreciated, and got me thinking a lot! Had I been implementing this from the start I would probably involve auto-incrementing u64's for context ID's and lots more bookkeeping, so it's great to get a new perspective! I am still thinking about your Or<A,B> concept for hashmaps and if I can use that pattern to simplify things elsewhere.

let me know if there's anything concerning in the above list, or if I missed something!

@shanecelis
Copy link
Contributor Author

shanecelis commented Jul 28, 2025

Thanks! I appreciate the kind words. I'm happy to have you take the reigns. I felt like I had been pretty intrusive with my changes, so I tried not to upset the apple cart where I could, but I'm glad you're reconsidering some things like Recipient, which doesn't quite cut along nice lines like it used to. So if you can make the recipients cut along the new lines which will be performant, I think that's a good change.

The only things I'd want preserved are handles for scripts, in-order loading (but it could be relaxed to be in-order per context), and a notion of domains. (I realized with Nano-9 if I use a "nano-9" domain instead of a shared context then I wouldn't have to monopolize BMS, and the user would still be free to use BMS in any fashion they like, which feels like a win.)

I look forward to seeing your changes. Happy to test and review when it's ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants